Skip to content

security(star-rating): authorize the /o?method=star ratings read - #7955

Open
ar2rsawseen wants to merge 6 commits into
masterfrom
security/star-method-authorize
Open

security(star-rating): authorize the /o?method=star ratings read#7955
ar2rsawseen wants to merge 6 commits into
masterfrom
security/star-method-authorize

Conversation

@ar2rsawseen

Copy link
Copy Markdown
Member

Summary

/o?method=star returns the platform and application-version combinations that have received star ratings. It performed no authorization, so any caller who supplies an app_id received that application's data with no api_key, no auth_token and no session.

Measured against the running API with no credentials at all:

GET /o?method=star&app_id=<app>&period=30days
  -> 200  {"Linux":["6:2:1","6:2:0"],"iOS":["1:23"],"tvOS":[...],"MacOS":[...]}

GET /o/feedback/data?app_id=<app>&period=30days        (sibling, same request, no creds)
  -> 400  {"result":"Missing parameter \"api_key\" or \"auth_token\""}

Why it was reachable

Authentication on /o is per method, not global. Every core method authorizes itself (case 'durations'validateUserForDataReadAPI(...), case 'events'validateUserForDataReadAPI(...)), and the default: branch hands the validators to plugins as helpers:

default:
    if (!plugins.dispatch(apiPath, {
        params: params,
        validateUserForDataReadAPI: validateUserForDataReadAPI,   // passed, never called
        ...
    })) { common.returnMessage(params, 400, 'Invalid path, ...'); }

dispatch returns truthy when a plugin claims the request, and the only fallback is 400 Invalid path when nothing does. So a plugin registering on /o must authorize the request itself. Every other plugin does (views/api/api.js:879validateRead at :886; times-of-day:177 → :185), and this plugin does it in its own siblings (/o/feedback/data:1258 → :1302, /o/feedback/widgets:1420 → :1422). The method === 'star' branch claimed the request, returned true, and never called a validator.

This was an omission rather than a design choice: the only caller is the dashboard Ratings page (starRatingPlugin.requestPlatformVersion in plugins/star-rating/frontend/public/javascripts/countly.models.js), which sends the session credential and the active app. The plugin's deliberately public endpoints are a separate, clearly named family (/o/sdk, /feedback/widgets, /i/feedback/input).

Impact

Cross-application disclosure of a specific customer's platform and application-version inventory, to an unauthenticated caller who knows the app_id. Rating comments and detailed feedback live in /o/feedback/data, which is authorized and unaffected. Medium under SECURITY.md.

Fix

Wrap the branch in validateRead(params, FEATURE_NAME, …), the same check the sibling reads already apply. Authorization runs before the period parameter is validated, so an unauthorized caller cannot probe the endpoint through its error responses.

app_id needs no extra guard: the core /o case rejects a request without one, and this branch only concatenates it into a collection name, so there is no ObjectID() conversion that could throw for a global admin (whose validateRead does not require app_id). The diff is mostly re-indentation from introducing the callback.

Scope of the class

Every plugin /o handler in countly-server, countly-platform and countly-enterprise-plugins was swept for a missing validator, and the surviving read candidates were probed unauthenticated. method=star is the only unauthenticated tenant-data read. The other credential-less responders return hardcoded lookup tables that ignore app_id (/o/langmap, /o/sources), are widget configuration for end-user SDKs (/o/feedback/widget), or are the intentional hooks API-endpoint trigger (/o/hooks). /o/surveys/*, /o/calculated_metrics/* and /o/journey-engine/folders all authorize correctly.

Reported through the security bug bounty program (received 2026-08-18).

🤖 Generated with Claude Code

The star-rating dashboard read performed no authorization. Authentication on /o is
per method: every core method calls validateUserForDataReadAPI itself, and the
default branch hands the validators to plugins as helpers without calling them, so a
plugin that claims a request is responsible for authorizing it. This branch claimed
the request, returned true, and never called a validator, so the endpoint answered
callers with no account, token or session, for any app_id they supplied.

What it disclosed is the set of platform and application-version combinations that
have received ratings for that application. Rating comments and the detailed feedback
in /o/feedback/data were not affected; those reads are authorized.

Wrap the branch in validateRead(params, FEATURE_NAME, ...), which is the same check
the sibling reads in this file already apply (/o/feedback/data and
/o/feedback/widgets). Authorization runs before the period parameter is validated, so
an unauthorized caller cannot probe the endpoint through its error responses.

app_id needs no extra guard here: the core /o case rejects a request without one, and
this branch only concatenates it into a collection name, so there is no ObjectID
conversion that could throw for a global admin whose validateRead call does not
require app_id.

The only caller is the dashboard Ratings page
(plugins/star-rating/frontend/public/javascripts/countly.models.js,
starRatingPlugin.requestPlatformVersion), which sends the session credential and an
app_id the member has access to, so it is unaffected.

Reported through the security bug bounty programme (received 2026-08-18).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
}
doc.meta.platform_version_rate.forEach(function(item) {
var data = item.split('**');
if (result[data[0]] === undefined) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Use a null-prototype map for stored platform names

data[0] comes from the public star-rating event's platform_version_rate segmentation and is used as an object key. Values such as __proto__, constructor, or toString resolve inherited members on {}, so this condition does not initialize an array and the following .indexOf() throws, making the authorized ratings read fail on attacker-seeded data. Build result with Object.create(null) or use an own-property check and explicitly initialize each key. The equivalent accumulator in the alternate/granular branch needs the same treatment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and it is a denial rather than a corruption — fixed in 10dcb09.

Ran the loop rather than reasoning about it. Against a plain object, each of these throws:

TypeError: result[data[0]].indexOf is not a function

for __proto__ (reads back as Object.prototype), constructor (the Object function), and toString / valueOf / hasOwnProperty (functions). None is undefined, so the "not seen yet" branch never runs and the array is never created. One planted row therefore fails the read for every authorized caller until it ages out — and the name comes off the public star-rating event's platform_version_rate segmentation, so anyone who can write to the app chooses it.

Took the Object.create(null) option:

var result = Object.create(null);

Nothing downstream changes — the keys are still ordinary strings, and JSON.stringify serialises a null-prototype object identically, which is what returnOutput does with it. There is a test pinning that.

And the granular branch too, as you asked: the platform copy has a second accumulator over data2.data[z]._id.split('**') and it gets the same treatment. The server copies only have the one.

On the tests: they lift both the accumulator's declaration and the loop out of the real source, rather than the loop alone. That matters here — a test that built its own result would pass whichever object the shipping code used. Four of the five fail against the previous code, with the TypeError above.

result[data[0]] === undefined is not a "have I seen this platform" test on a plain object.
The platform name comes off the public star-rating event, in the platform_version_rate
segmentation, so anyone who can write to the app chooses it, and "__proto__",
"constructor", "toString", "valueOf" and "hasOwnProperty" all read back as inherited
members rather than as undefined. The array is then never created and the next line throws

    TypeError: result[data[0]].indexOf is not a function

so one planted row denies the whole read for every authorized caller until it ages out.
Verified by running the loop, not by reading it.

Built with Object.create(null) instead. Nothing else changes: the keys are still ordinary
strings, and JSON.stringify serialises a null prototype object identically, which is what
returnOutput does with it. The platform has a second accumulator in the granular branch
and it gets the same treatment.

Tests lift both the accumulator's declaration and the loop out of the real source, so the
choice of object is what is under test rather than one the test made for itself. Four of
the five fail against the previous code, with the TypeError above.
ar2rsawseen added a commit that referenced this pull request Aug 25, 2026
The four two-factor-auth cases I added were the only new failures in test-api-core on
this branch. They allowed 403 or 404, on the assumption that a build without the plugin
answers 404. It does not: requestProcessor answers 400 "Invalid path", so the cases
failed everywhere the plugin is not enabled - which is the CI test build.

Widening the allowed set to include 400 would have been the wrong repair. A case that
accepts 400, 403 and 404 passes whether or not the guard exists, which is worse than no
case at all. They now probe once for the route and skip with a reason when it is absent,
and assert exactly 403 when it is there.

Skipping leaves the guard unproven in this build, so the coverage that does not depend on
the plugin being installed is added beside it: a unit suite that reads the plugin source
and requires refuseScopedCredential at each method that mutates the factor, before the
write rather than after it, and requires the global-admin methods to keep using
validateUserForGlobalAdmin. Removing the guard from one arm fails it, checked by doing
exactly that. On 24.05 the generate-qr-code case reports pending, because that branch has
no such method.

For the record on the rest of this branch's test-api-core run: the other three failures
("user permission when app is deleted", "correct admins and users", "should return one
user") are not from this change. The same three fail on #7930, which carries none of it,
and test-api-core passed in the same hour on #7923, #7935, #7941, #7955, #7970, #7894 and
#7871. That is the flaky trio, not a regression.
ar2rsawseen added a commit that referenced this pull request Aug 25, 2026
The four two-factor-auth cases I added were the only new failures in test-api-core on
this branch. They allowed 403 or 404, on the assumption that a build without the plugin
answers 404. It does not: requestProcessor answers 400 "Invalid path", so the cases
failed everywhere the plugin is not enabled - which is the CI test build.

Widening the allowed set to include 400 would have been the wrong repair. A case that
accepts 400, 403 and 404 passes whether or not the guard exists, which is worse than no
case at all. They now probe once for the route and skip with a reason when it is absent,
and assert exactly 403 when it is there.

Skipping leaves the guard unproven in this build, so the coverage that does not depend on
the plugin being installed is added beside it: a unit suite that reads the plugin source
and requires refuseScopedCredential at each method that mutates the factor, before the
write rather than after it, and requires the global-admin methods to keep using
validateUserForGlobalAdmin. Removing the guard from one arm fails it, checked by doing
exactly that. On 24.05 the generate-qr-code case reports pending, because that branch has
no such method.

For the record on the rest of this branch's test-api-core run: the other three failures
("user permission when app is deleted", "correct admins and users", "should return one
user") are not from this change. The same three fail on #7930, which carries none of it,
and test-api-core passed in the same hour on #7923, #7935, #7941, #7955, #7970, #7894 and
#7871. That is the flaky trio, not a regression.
The changelog is generated from PR and commit titles later, so an entry written by hand
here is duplicated work at best. It is also the single worst file in this wave for
conflicts: every merge to the base appends a line, which re-conflicts every open branch
that also appends one. Eight of the sixteen conflicts across these security PRs today
were this file and nothing else, and two of them came back within the hour.

Only the lines this branch added are removed - the file is otherwise the base's, and the
change here was purely additive, so nothing else moves.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant